Skip to content

walletrpc: add XCreateAccount for wallet-derived accounts - #11065

Merged
yyforyongyu merged 6 commits into
lightningnetwork:masterfrom
ellemouton:agent/walletrpc-create-account
Aug 17, 2026
Merged

walletrpc: add XCreateAccount for wallet-derived accounts#11065
yyforyongyu merged 6 commits into
lightningnetwork:masterfrom
ellemouton:agent/walletrpc-create-account

Conversation

@ellemouton

@ellemouton ellemouton commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Motivation

lnd can already confine coin selection, change, balance, address derivation and
signing to a named wallet account — FundPsbt, FinalizePsbt, ListUnspent,
NextAddr, WalletBalance and ListTransactions all take one. What is missing
is a way to create such an account.

The only account-creating RPC today is ImportAccount, which registers a
watch-only account from an extended public key. The wallet stores no account
private key for it, so waddrmgr can only derive public keys for its addresses
and SignPsbt/FinalizePsbt silently skip its inputs. That makes it unusable
for partitioning one wallet into isolated pockets of funds, which is what two
applications sharing a single lnd node need in order not to spend each other's
coins.

btcwallet already supports this via Wallet.NextAccount, which derives a new
account from the wallet's master key. lnd simply never exposed it.

Changes

Split so each commit stands alone:

  1. walletrpc: define the XCreateAccount RPC — proto, REST annotation, regen.
  2. lnwallet: add CreateAccount to the WalletController interface — the
    BtcWallet implementation, the two mocks, and an explicit refusal in
    RPCKeyRing.
  3. walletrpc: implement the XCreateAccount RPC — the handler.
  4. itest: cover XCreateAccount end to end.
  5. lncli: add wallet accounts create command.
  6. docs: add release notes for XCreateAccount.

Notes for reviewers

  • Duplicates are rejected across all key scopes, not just the requested one.
    Coin selection resolves a custom account through lookupFirstCustomAccount,
    which returns whichever scope matches first, so the same name under two scopes
    would make later funding calls ambiguous.

  • NESTED_WITNESS_PUBKEY_HASH is rejected. A wallet-derived account stores
    no address schema, so BIP-0049Plus always behaves as the hybrid scheme.
    Accepting the strict type would hand back an account whose change outputs are
    not what was asked for. ImportAccount can honour the distinction because it
    passes an addrSchema through; NextAccount cannot.

  • Remote signing is refused explicitly. RPCKeyRing embeds the
    WalletController interface, so without an override it would promote
    BtcWallet's implementation and fail deep inside waddrmgr with a bare
    "watching-only wallet". Creating the account on the signer and importing its
    xpub here is the supported path.

  • Two caveats are documented in the proto and are worth a second opinion:

    • The address type is permanent and also fixes the account's change type, so
      a later NextAddr must ask for the same address type or the account will
      appear not to exist.
    • A seed-only recovery will not rediscover funds held in a created
      account: lnd's recovery scan only derives addresses for account 0. Ordinary
      rescans are unaffected.
  • BtcWallet.CreateAccount reaches NextAccount through a local interface
    assertion rather than widening btcwallet's base.Interface, so lnd does not
    have to carry a forked btcwallet — a replace here would not propagate to
    modules that depend on lnd, and each of them would have to duplicate it. Happy
    to do the btcwallet PR instead; the assertion is marked for removal if so.

Testing

Unit tests cover the guards, key-scope forwarding, the unsupported-wallet path
and error wrapping. make rpc-check and the REST-annotation check pass.

There are also two itests, with the matching HarnessRPC helpers. The first
covers the property a unit test cannot reach: create → NewAddress → fund →
FundPsbtFinalizePsbt → publish, asserting the balance lands on the new
account and not the default one, and that the spend confirms. Funding a PSBT
works for a watch-only account too, since it needs only public data — it is
finalizing and confirming that separates a wallet-derived account from an
imported one. The second covers the refusals: duplicate names across key
scopes, reserved names, an empty name, and the strict nested-witness type.

🤖 Generated with Claude Code

@ellemouton
ellemouton force-pushed the agent/walletrpc-create-account branch from b4e3c93 to b5bd4e5 Compare August 12, 2026 20:15
@github-actions github-actions Bot added the severity-critical Requires expert review - security/consensus critical label Aug 12, 2026
@github-actions

Copy link
Copy Markdown

🔴 PR Severity: CRITICAL

gh pr view | 16 files | 386 lines changed (excluding tests/generated)

🔴 Critical (4 files)
  • lnwallet/btcwallet/btcwallet.go - wallet controller implementation (account/address derivation)
  • lnwallet/interface.go - core WalletController interface definition
  • lnwallet/mock.go - wallet controller mock implementing the interface
  • lnwallet/rpcwallet/rpcwallet.go - remote-signer wallet controller
🟠 High (5 files)
  • lnrpc/walletrpc/walletkit_server.go - WalletKit RPC server implementation
  • lnrpc/walletrpc/walletkit.pb.go - generated protobuf types (auto-generated)
  • lnrpc/walletrpc/walletkit.pb.gw.go - generated gRPC-gateway code (auto-generated)
  • lnrpc/walletrpc/walletkit.pb.json.go - generated JSON marshaling (auto-generated)
  • lnrpc/walletrpc/walletkit_grpc.pb.go - generated gRPC service code (auto-generated)
🟡 Medium (4 files)
  • cmd/commands/walletrpc_active.go - CLI command for the new WalletKit RPC
  • lnrpc/walletrpc/walletkit.proto - API definition change (new RPC)
  • lnrpc/walletrpc/walletkit.swagger.json - generated swagger spec
  • lnrpc/walletrpc/walletkit.yaml - REST annotations
🟢 Low (3 files)
  • docs/release-notes/release-notes-0.22.0.md - release notes
  • lnwallet/btcwallet/create_account_test.go - test-only change
  • lntest/mock/walletcontroller.go - test mock

Analysis

This PR adds a new WalletKit RPC and plumbs it through lnwallet's
core wallet controller interface and its btcwallet/rpcwallet
implementations. Because it touches lnwallet/* (wallet operations,
account derivation), it falls into the critical tier regardless
of the accompanying RPC/CLI plumbing, which requires review from
someone familiar with wallet internals and the remote-signer code
path.


To override, add a severity-override-{critical,high,medium,low} label.

@litbot-9000 litbot-9000 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at commit b5bd4e5, built and tested locally against the pinned btcwallet v0.18.0 in the module cache.

The shape of this is right: ImportAccount genuinely cannot produce a spendable account, NextAccount is the correct primitive, and the guard rails you picked are the ones that matter. Most of the load-bearing claims in the description check out (details below). One blocking item, in the docs rather than the code.

What I verified, and how

  • Each of the 5 commits builds standalone. git checkout <commit> && go build ./... && go vet ./lnwallet/... ./lnrpc/walletrpc/... at each of 48abc65, 43524c3, 7b5955c, c9a917d, b5bd4e5 — all exit 0.
  • Tests pass. go test ./lnwallet/btcwallet/... ./lnrpc/walletrpc/... ./lnwallet/rpcwallet/... — all ok.
  • make rpc-check I could NOT run. It shells out to gen_protos_docker.sh and there is no docker or protoc in this container (make: *** [Makefile:446: rpc] Error 127). I did not verify the generated stubs match the proto; I am taking your word that it passes.
  • Duplicate rejection really does cover every scope, and is not hardcoded. ListAccounts(name, nil) with a custom name and lookupFirstCustomAccount (lnwallet/btcwallet/psbt.go:627) both iterate the same waddrmgr.DefaultKeyScopes, so a scope added upstream propagates to both automatically. Note that slice is {49+, 84, 86, 44} — wider than lnd's own LndDefaultKeyScopes — which is the conservative direction. Claim holds.
  • NESTED_WITNESS_PUBKEY_HASH rejection is correct. waddrmgr.ScopeAddrMap[KeyScopeBIP0049Plus] is {External: NestedWitnessPubKey, Internal: WitnessPubKey}, and marshalWalletAccount maps a nil AddrSchema under that scope to HYBRID_... (walletkit_server.go:2544). NextAccount has no addrSchema parameter, so the strict type is genuinely unrepresentable. Rejecting rather than substituting is the right call.
  • RPCKeyRing is the only embedder that needed an override. I grepped for embedders of lnwallet.WalletController: there are exactly two, RPCKeyRing (rpcwallet.go:58) and lnwallet.LightningWallet (wallet.go:419). The latter promotes whatever controller is configured, and under remote signing config_builder.go:891 wires rpcKeyRing in as WalletController, so cc.Wallet.CreateAccount reaches your override rather than BtcWallet's. No latent promotion left.
  • Macaroons and REST. onchain/write matches ImportAccount/ImportPublicKey; POST /v2/wallet/accounts/create is consistent with /v2/wallet/accounts/import. Both fine.
  • The seed-only recovery caveat is real. btcwallet's RecoveryManager.Resurrect rederives only for waddrmgr.DefaultAccountNum (wallet/recovery.go:72, wallet/wallet.go:1110 and :1152), with a standing upstream TODO right there: // TODO(conner): rescan for all created accounts if we allow users to use non-default address. Confirmed.

The blocking item

The caveat is correctly identified, but the mitigation the proto gives does not work, and an operator who follows it literally will conclude their coins are gone when they are recoverable. Details inline on walletkit.proto. This is docs-only, but it is the difference between "annoying manual procedure" and "funds lost", on an API that is permanent once released and that is about to hold real mainnet coins. Worth getting exactly right before it ships rather than in a follow-up.

A proto comment plus a release note is, in my view, sufficient warning — I do not think this needs an lncli y/N confirmation prompt, and I would not want one. But the warning has to be actionable, and lncli should surface it at all (it currently does not).

Your two questions

(a) itest + HarnessRPC helper — yes, in this PR. The unit tests are good at what they cover (guards, scope forwarding, error wrapping) but they run against a hand-rolled fake that never touches waddrmgr, so the one claim the whole PR exists to make — this account, unlike an imported one, can actually sign for its own outputs — is not exercised anywhere in CI. "Exercised end-to-end downstream" does not help upstream lnd, and for what it's worth neither lightninglabs/wavelength#1140 nor lightninglabs/lumos#786 covers the account path end to end either, so if it does not land here it exists nowhere. A single itest doing create → NextAddr → fund → FundPsbtSignPsbt → confirm would also pin down the address-type round-trip in the second inline comment below, which is the part I would most expect to regress.

(b) btcwallet PR vs local assertion — do both, in that order of importance, but do not block this PR on it. Your replace-doesn't-propagate reasoning is correct: Go ignores replace directives in non-main modules, so a fork here would silently do nothing for every downstream consumer of lnd and each would have to duplicate it. That is a real cost and the assertion is the right short-term call. It also degrades safely — I checked, *wallet.Wallet satisfies it, and a backend that does not gets a clear typed error rather than a panic. Open the btcwallet PR to widen base.Interface in parallel and drop the assertion on the next dep bump; the TODO comment already says as much.

What I did NOT verify

  • make rpc-check / generated stub correctness (no toolchain here).
  • No itest run; I have not observed a created account actually receive and spend coins on regtest. Everything I say about spendability is from reading waddrmgr/btcwallet, not from running it.
  • The two sibling PRs are in private repos I did not read.

— claudell ⚡

Comment thread lnrpc/walletrpc/walletkit.proto Outdated
NOTE: Funds held in an account created here are not rediscovered by a
seed-only recovery. lnd's recovery scan only derives addresses for the
wallet's default account, so restoring from the aezeed alone will not
find them; back up the account name alongside the seed and re-create the

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: the caveat is right but the recovery procedure given here does not work, in two separate ways. Someone following it exactly will rescan, find nothing, and reasonably conclude the coins are unrecoverable.

  1. The account name is not part of the derivation — the account index is. ScopedKeyManager.NewAccount assigns fetchLastAccount(ns, &s.scope) + 1 (waddrmgr/scoped_manager.go:1625-1633), and NewAccountWatchingOnly shares that same counter (:1742). So backing up the name buys you nothing: what has to be reproduced is the key scope and the account index, which means re-creating every account in that scope — including any ImportAccount ones — in the original order, so the counter lands on the same value. Two accounts recreated in the wrong order gives you two accounts with each other's addresses.

  2. Re-creating the account is not sufficient, because a rescan only looks for addresses already in the wallet DB. Wallet.activeData feeds the rescan from Manager.ForEachRelevantActiveAddress (waddrmgr/manager.go:827), i.e. addresses that have already been derived. A freshly created account has ExternalKeyCount == 0, so a rescan over it searches for zero addresses. The user also has to re-derive at least as many addresses as were previously used — NextAddr in a loop against the recreated account — before triggering the rescan.

So the accurate procedure is roughly: record the key scope, the account index, and the number of addresses issued; on restore, re-create accounts in that scope in original order until the index matches, call NextAddr at least that many times, then start with --reset-wallet-transactions. That is a genuinely recoverable situation, which is a much better story than the current text tells — worth spelling out rather than leaving as a flat "will not rediscover".

It is also worth citing the reason it is this way, since it bounds how permanent the caveat is: btcwallet's RecoveryManager.Resurrect hardcodes waddrmgr.DefaultAccountNum (wallet/recovery.go:72) and carries an explicit // TODO(conner): rescan for all created accounts if we allow users to use non-default address — which this RPC is precisely the trigger for. Might be worth opening that btcwallet issue alongside the base.Interface one.

Comment thread lnrpc/walletrpc/walletkit.proto Outdated
NOTE: The account's address type is permanent and also fixes the type of
its change outputs. lnd resolves a custom account name within the key
scope implied by the requested address type, so later calls such as
NextAddr must ask for the same address type or the account will appear

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"later calls such as NextAddr must ask for the same address type" is not followable for one of the three types you accept. NewAddress/NextAddr take lnrpc.AddressType, which has no HYBRID_* member — it is {WITNESS_PUBKEY_HASH, NESTED_PUBKEY_HASH, TAPROOT_PUBKEY, ...} (lnrpc/lightning.proto:1193). An account created as HYBRID_NESTED_WITNESS_PUBKEY_HASH has to be addressed with NESTED_PUBKEY_HASH, since rpcserver.go:1698 maps that to lnwallet.NestedWitnessPubKeyKeyScopeBIP0049Plus in keyScopeForAccountAddr. Suggest naming the mapping explicitly rather than saying "the same address type".

Separately, and the reason this caveat keeps costing people time: the failure mode is a bare not-found. keyScopeForAccountAddr (lnwallet/btcwallet/btcwallet.go:470) calls AccountNumber(addrKeyScope, accountName) and returns waddrmgr's account name 'x' not found verbatim — no hint that the account exists, just under a different scope. Given this same edge has now had to be worked around independently in lightninglabs/wavelength#1140 and lightninglabs/lumos#786 and documented a third time here, the error message is arguably the actual bug. A cheap fix in the not-found branch: fall back to lookupFirstCustomAccount, and if the name does resolve under another scope, say so — account "x" exists under key scope %v, not %v; request address type %v instead. Non-blocking, but it would retire the footgun instead of documenting it a fourth time.

// lookupFirstCustomAccount, which returns whichever scope happens to
// match first, so the same name existing under two scopes would make
// every later funding call for that name ambiguous.
_, err := b.ListAccounts(name, nil)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified the reasoning here: ListAccounts(name, nil) on the custom-name path and lookupFirstCustomAccount both range over waddrmgr.DefaultKeyScopes, so the check covers exactly the set that could later become ambiguous, and a scope added upstream propagates to both without touching this code. Good.

One gap worth a line of acknowledgement: this check and the NextAccount below are separate db transactions, and btcwallet's own duplicate check (newAccountlookupAccount, scoped_manager.go:1654) is per-scope. Two concurrent CreateAccount calls with the same name under different scopes therefore both pass here and both succeed, producing exactly the ambiguity this guard exists to prevent. Realistically an operator does not race themselves, so I would not restructure for it — but a NOTE: saying the cross-scope invariant is best-effort and not atomic would stop the next reader assuming it is enforced.

// propagate to modules that depend on lnd, and would have to be
// duplicated by every one of them. This assertion can be dropped once
// NextAccount is part of base.Interface upstream.
creator, ok := b.wallet.(accountCreator)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked the degradation path since it is the thing an interface assertion usually gets wrong: b.wallet is a base.Interface, the concrete *wallet.Wallet satisfies NextAccount (btcwallet@v0.18.0 wallet/wallet.go:2090), and anything else gets this typed error rather than a nil-deref. The %T is a nice touch for diagnosing it. No objection to landing it as-is.

On the choice itself, answering the question in the description: the replace argument is correct and is the deciding factor — Go ignores replace directives in non-main modules, so forking btcwallet here would be a no-op for every consumer of lnd and each would have to carry its own copy. Widening base.Interface upstream is still the right end state; open that PR in parallel and drop this on the next btcwallet bump, which is what the comment already commits to.

Comment thread lnwallet/rpcwallet/rpcwallet.go Outdated
func (r *RPCKeyRing) CreateAccount(waddrmgr.KeyScope,
string) (*waddrmgr.AccountProperties, error) {

return nil, fmt.Errorf("creating accounts is not supported when " +

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed this is needed and that it is the only place that needs it — RPCKeyRing and lnwallet.LightningWallet (lnwallet/wallet.go:419) are the only two embedders of WalletController, and under remote signing config_builder.go:891 installs rpcKeyRing as LightningWallet.WalletController, so cc.Wallet.CreateAccount lands here. Without it you would indeed have gotten ErrWatchingOnly from scoped_manager.go:1611, which is exactly the unhelpful bare error you describe. Error text points at the right alternative too.

Only nit: this returns a plain error, so it surfaces over gRPC as Unknown. status.Error(codes.Unimplemented, ...) would let callers branch on it — mildly useful for the downstream consumers, which have to decide whether to fall back to the default account. Take it or leave it.


The address type permanently fixes the key scope the account lives in,
and therefore the address type of both its receive and its change
outputs. It defaults to taproot and cannot be changed afterwards.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The description covers the permanence of the address type but says nothing about the recovery caveat, which is the part that can actually cost someone money. I do not think this needs an interactive y/N — that gets in the way of scripted operator setup and people click through prompts anyway — but the caveat should at least appear here, and ideally be echoed to stderr after a successful create, along the lines of: funds in this account are not found by a seed-only restore; record the account's key scope and index and see before depositing.

That also makes the lncli output the place the operator learns the index, which per the note on walletkit.proto is the thing they actually need to have written down. printRespJSON(resp) already returns the Account with its derivation_path, so it is right there — just worth pointing at.

// names of the wallet's built-in accounts ("default" and "imported") are
// reserved and cannot be used.
string name = 1;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wondering if we should expose the ability to set an explicit key family/account here. As in other codebases we may already have a region carved out.

If say keyfam 77 is taken, and we allow it to be specified, what would be the resulting behavior?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question, and the answer is reassuring: key families and accounts are different namespaces, so a taken keyfam can't collide here.

lnd's own keys live under the BIP-0043 purpose 1017 scope (keychain.BIP0043Purpose), where the account slot is what carries the KeyFamily — so keyfam 77 is m/1017'/<coin>'/77'. CreateAccount only ever writes into the BIP-49Plus/84/86 scopes (whichever the requested address type maps to), so an account created here is m/86'/0'/N' and can never land on m/1017'/…/77'. They can't overlap, and ListAccounts doesn't surface 1017 accounts as named accounts either.

On exposing the index explicitly: I'd like to, but btcwallet doesn't currently allow it. ScopedKeyManager.NewAccount assigns fetchLastAccount + 1 unconditionally — there's no "create at index N" entry point, and the same counter is shared with ImportAccount. So accepting an explicit index would mean either a btcwallet change or creating-and-discarding accounts until the counter lands where you asked, which seems worse than not offering it.

That matters more than it first looks, because the index — not the name — is what a seed-only recovery needs (the recovery scan only rederives account 0, so restoring a created account means reproducing its scope and index). The response does return it: Account.derivation_path carries the full path, and lncli now points at it after a successful create for exactly that reason.

If you'd like an explicit index, I'm happy to open the btcwallet PR to add a NewAccountAtIndex-style entry point and follow up here — it'd pair naturally with the base.Interface widening this PR already wants.

Comment thread lnwallet/btcwallet/btcwallet.go Outdated
// key scope.
accountNumber, err := b.wallet.AccountNumber(addrKeyScope, accountName)
if err != nil {
// A custom account lives in exactly one key scope, so asking

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A custom account lives in exactly one key scope

Meaning the BIP 86/84 scope?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes — one of BIP-0049Plus, BIP-0084 or BIP-0086, whichever the address type at creation mapped to, and fixed for the account's lifetime. I've named the three in the comment rather than leaving it implicit.

// propagate to modules that depend on lnd, and would have to be
// duplicated by every one of them. This assertion can be dropped once
// NextAccount is part of base.Interface upstream.
creator, ok := b.wallet.(accountCreator)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice lil assertion trick here

// below are separate database transactions, and btcwallet's own
// duplicate check is per-scope, so two concurrent calls naming the
// same account under different scopes can both succeed.
_, err := b.ListAccounts(name, nil)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a potential race here. List then next isn't under the same db transaction, so another concurrent caller can win over.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't have anything exposed today to handle this in a single unit, so perhaps a mutex is the best we can do here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and done — added a createAccountMtx held across both the duplicate check and NextAccount. As you say there's nothing that does the two in one db transaction today, so a mutex is what's available; the comment says so explicitly and notes it only serialises callers within this process (lnd is the sole writer of its own wallet).

Also added TestCreateAccountSerialisesCallers, which fails without the mutex — the wallet fake reports the greatest number of callers it ever saw inside the check-then-create section, so it asserts serialisation directly rather than trying to lose a race by chance. Worth flagging because my first attempt at that test still passed with the mutex removed, which made it worthless.

@ellemouton
ellemouton force-pushed the agent/walletrpc-create-account branch from 3117a4d to 5d1dabe Compare August 13, 2026 15:56
@ziggie1984
ziggie1984 self-requested a review August 13, 2026 17:02
@ziggie1984

Copy link
Copy Markdown
Collaborator

One scope clarification that may be worth making explicit in the RPC/CLI docs: the account separation boundary ends at the on-chain wallet layer. Normal and batch channel funding still select from the default account; a custom account can fund a channel through the PSBT flow, but once those UTXOs enter the channel state machine the channel carries no originating-account association, and later sweep outputs generally return to the default account. So this provides useful UTXO/PSBT isolation, but not end-to-end per-application channel or Lightning accounting. The current “isolated pocket of funds” wording could otherwise be read as covering the full channel lifecycle.

@ziggie1984 ziggie1984 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Had a look at this specifically for races, lock structure and RPC lifecycle. Overall it looks solid — go vet is clean (so the new mutex field doesn't trip copylocks), and go test -race -run TestCreateAccount ./lnwallet/btcwallet/ passes.

No deadlocks. createAccountMtx is a leaf lock: everything under it (ListAccounts -> AccountPropertiesByName, NextAccount, AccountProperties) descends into btcwallet/waddrmgr and never re-enters BtcWallet, so there's no ordering cycle and no re-entrancy back into CreateAccount.

RPC lifecycle is clean. In lncli, parseAddrType runs before getWalletClient, so the validation error path never dials, and defer cleanUp() covers every other path. Both lntest/rpc helpers use context.WithTimeout(h.runCtx, ...) with defer cancel().

The one thing I'd want addressed before merge is the ImportAccount gap below — the new mutex closes the CreateAccount-vs-CreateAccount window but leaves CreateAccount-vs-ImportAccount open, which is the same invariant. Rest is minor.

Comment thread lnwallet/btcwallet/btcwallet.go Outdated
// one database transaction, so hold this for both. It only serialises
// callers within this process; nothing stops a second process driving
// the same wallet, but lnd is the sole writer of its own wallet.
b.createAccountMtx.Lock()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mutex closes the CreateAccount-vs-CreateAccount window, but the same cross-scope invariant is still racy against ImportAccount.

BtcWallet.ImportAccount (btcwallet.go:936-950 on this branch) does the identical check-then-act — b.ListAccounts(name, nil) followed by b.wallet.ImportAccount(...) — and takes no lock. Since gRPC handlers run concurrently, CreateAccount("foo", BIP0086) and ImportAccount("foo", ...) can both pass their duplicate checks and both succeed, leaving one name under two key scopes. That's exactly the ambiguity this mutex exists to prevent, just reached from the other side.

Suggest renaming it to accountMtx and taking it in ImportAccount too, wrapping the ListAccounts check through both the dry-run and non-dry-run branches (the dry run also consults ListAccounts before deciding).

Worth noting the ImportAccount half is pre-existing on master, so it could also be a follow-up — but since this PR is the one establishing the invariant, it seems natural to close it here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — closed. Renamed to accountMtx and ImportAccount now takes it too, held across its ListAccounts check and both the dry-run and non-dry-run branches. You're right that it's the same invariant reached from the other side, and that it was worth doing here since this is the PR that establishes the invariant.

// The wallet creates both of these accounts itself, in every key scope,
// and neither is backed by a derived account key we could recreate
// here.
if name == lnwallet.DefaultAccountName ||

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the empty-name check sits above Lock() but this reserved-name check is below it, even though it's a pure string comparison that touches no shared state. Would read better with all the input validation grouped before the critical section.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved — the reserved-name check now sits with the empty-name check above Lock(), so all the pure input validation is grouped before the critical section.

accountName,
)
if lookupErr == nil {
return waddrmgr.KeyScope{}, 0, fmt.Errorf(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heads up that this converts a typed waddrmgr.ManagerError{ErrAccountNotFound} into an untyped fmt.Errorf on the "exists under a different scope" path, which is a silent contract change for NewAddress/LastUnusedAddress.

Nothing outside lnwallet/btcwallet inspects that code today, so it's safe as-is — and %w wouldn't help anyway since waddrmgr.IsError type-asserts rather than unwrapping. Just worth a line in the commit message so it's not a surprise later.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noted in the commit message, thanks. It now says plainly that keyScopeForAccountAddr reports a wrong-scope lookup as an untyped error naming the scope the account does live in, rather than passing through waddrmgr's typed ErrAccountNotFound, and why that's safe today.

Comment thread lnwallet/rpcwallet/rpcwallet.go Outdated
func (r *RPCKeyRing) CreateAccount(waddrmgr.KeyScope,
string) (*waddrmgr.AccountProperties, error) {

return nil, status.Error(codes.Unimplemented, "creating accounts is "+

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things about returning a gRPC status from the wallet layer here:

  1. The file's convention for unsupported-in-remote-signing ops is a plain error — see ErrRemoteSigningPrivateKeyNotAvailable at the top of this file.
  2. codes.Unimplemented has a specific meaning in gRPC: "the server does not implement this method". Clients and version-negotiation logic routinely read it as "peer is too old" and fall back accordingly. Here the method is implemented — it's the node's configuration that forbids it. codes.FailedPrecondition conveys that accurately, or just a plain error to match the rest of the file.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on both counts, and the second is the stronger argument — the method is implemented, it's the configuration that forbids it, so Unimplemented would actively mislead version negotiation. Switched to a plain error following the file's convention: a package-level ErrRemoteSigningAccountCreation alongside ErrRemoteSigningPrivateKeyNotAvailable.

Comment thread lnrpc/walletrpc/walletkit_server.go Outdated
// outputs. That makes it usable as an isolated pocket of funds inside a single
// wallet, because coin selection, change, balance and address derivation can
// all be scoped to it by name.
func (w *WalletKit) CreateAccount(_ context.Context,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The handler drops the context, which is consistent with the other WalletKit handlers — but this one mutates persistent state, so the consequence is a bit sharper: a client that hits its deadline or cancels still gets the account created, and its retry then fails with "already exists" with no way to distinguish that from a genuine name clash.

Not worth plumbing a context through WalletController for, but a NOTE in the proto docs that the call isn't idempotent and that "already exists" may be the result of a retried-but-successful create would save someone a debugging session.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added to the proto docs: the call is not idempotent, the account is created before the response is sent, so a cancelled or timed-out client may still have had it created and its retry will fail with "already exists" indistinguishably from a real clash — with a pointer to check ListAccounts before retrying.

func (w *serialisingWallet) AccountPropertiesByName(_ waddrmgr.KeyScope,
name string) (*waddrmgr.AccountProperties, error) {

w.enter()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

enter()/exit() only instrument AccountPropertiesByName, so maxInFlight() == 1 proves the check is serialised — not that the check and the creation sit in the same critical section.

A lock that wrapped only the ListAccounts call would pass this test while leaving the actual bug intact, and check+create atomicity is the whole property the mutex exists for. Instrumenting NextAccount with the same enter()/exit() pair would close that.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, and this is the more useful half of the property — the test as written would have passed a lock that covered only the lookup. NextAccount is now instrumented with the same enter()/exit() pair. I verified it both ways: it fails when the lock is narrowed to just the ListAccounts call, and passes with the lock spanning check and create.

w := &BtcWallet{wallet: fake}

var wg sync.WaitGroup
for i := 0; i < callers; i++ {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style: for i := range callers per the repo convention. The go func(i int) parameter is also unnecessary on Go >= 1.22 now that the loop var is per-iteration.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — for i := range callers, and dropped the now-unnecessary loop-variable parameter.


// The spend confirmed, so the account still holds its funds minus
// fees, and the default account is still untouched by any of it.
ht.AssertWalletAccountBalance(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says "the account still holds its funds minus fees", but only the default account is asserted here — the custom account's post-spend balance never gets checked.

That's the strongest post-condition in the test: it's what proves the spend came out of the account and the change went back into it rather than leaking to the default account. Worth an AssertWalletAccountBalance on createAccountName too (or trimming the claim from the comment).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added rather than trimmed, since you're right that it's the strongest post-condition. The test now reads the account's own confirmed balance back from WalletBalance's per-account map and asserts it is below the funded amount (a fee was paid) but above funded-minus-a-fee-ceiling — which is what shows the inputs came from the account and the change returned to it, not to default.

return cli.ShowCommandHelp(ctx, "create")
}

addrType, err := parseAddrType(ctx.String("address_type"))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseAddrType maps np2wkh to NESTED_WITNESS_PUBKEY_HASH, which the server unconditionally rejects. The flag's own usage string already omits it, so the CLI accepts a value it knows will fail.

Rejecting it locally would give the user the better "use np2wkh-p2wkh instead" message without the round trip.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — lncli now rejects np2wkh locally with the "use np2wkh-p2wkh instead" message, before dialling.

@Roasbeef

Copy link
Copy Markdown
Member

Normal and batch channel funding still select from the default account; a custom account can fund a channel through the PSBT flow,

Yeah that's intended, this is for on-chain wallet isolation mainly.

@ellemouton
ellemouton force-pushed the agent/walletrpc-create-account branch 2 times, most recently from 1abeff2 to 5048e99 Compare August 13, 2026 22:54
Declares the RPC and its messages, and regenerates the stubs. The
implementation follows in the next commits.

The account's address type selects the BIP-0043 key scope it is
created under, which is permanent and also fixes the address type of
its change outputs, so the proto spells out both that pairing and
the fact that a seed-only recovery does not rediscover funds held in
such an account.

The X prefix follows XImportMissionControl and the
XAddLocalChanAliases family: it marks the API as experimental, so it
may change or be removed without the usual deprecation period. It
comes off once a seed-only recovery can find these accounts.
Adds the wallet-side operation the RPC will call, implemented by
BtcWallet through btcwallet's Wallet.NextAccount. Unlike
ImportAccount, which registers a watch-only account from an extended
public key and whose inputs the wallet can therefore never sign, the
account created here is derived from the wallet's master key and is
fully spendable.

NextAccount is reached through a local interface assertion rather
than by widening btcwallet's base.Interface, so lnd does not have to
carry a forked btcwallet: a replace directive here would not
propagate to modules that depend on lnd and each of them would have
to duplicate it.

Duplicate names are rejected across every key scope, not just the
requested one, because coin selection resolves a custom account
through lookupFirstCustomAccount, which returns whichever scope
matches first; the same name under two scopes would make later
funding calls ambiguous. The wallet's own reserved names are
refused too.

RPCKeyRing refuses the operation outright. It embeds the
WalletController interface, so it would otherwise promote this
implementation and fail deep inside waddrmgr with a bare
"watching-only wallet"; creating the account on the remote signer
and importing its extended public key is the supported path.

The same lock is taken by ImportAccount, which does the identical
check-then-act against the same namespace and would otherwise let a
concurrent pair create one name under two key scopes from the other
side of the invariant.

Note one deliberate contract change: keyScopeForAccountAddr now
reports a wrong-scope lookup as an untyped error naming the scope the
account does live in, rather than passing through waddrmgr's typed
ErrAccountNotFound. Nothing outside this package inspects that code,
and the bare "not found" it replaces is the reason this edge has had
to be worked around several times downstream.
Maps the requested address type onto the key scope the account is
created in, mirroring ListAccounts, and defaults an unset type to
taproot.

NESTED_WITNESS_PUBKEY_HASH is rejected rather than served. An
account derived by the wallet stores no address schema of its own,
so BIP-0049Plus always behaves as the hybrid scheme; honouring the
strict request is impossible here and silently substituting the
hybrid one would return an account whose change outputs are not what
the caller asked for. ImportAccount can honour the distinction
because it passes an address schema through.

Creation is additionally gated on an explicit acknowledgement,
following AbandonChannel: a dev build passes, and a release build
requires i_know_what_i_am_doing. Funds held in a created account are
not rediscovered by a seed-only restore, so a caller has to state
that it accepts that before one is made.

The X prefix alone does not carry that, and a dev-build-only gate
would not either: release images are what real deployments run, so
it would have put the RPC out of reach of exactly the nodes that
need the isolation. The acknowledgement keeps it unreachable by
accident while leaving it usable by an operator who has read what
they are signing up for.
@ellemouton
ellemouton force-pushed the agent/walletrpc-create-account branch from 5048e99 to 4caca49 Compare August 13, 2026 23:17
@ellemouton ellemouton changed the title walletrpc: add CreateAccount for wallet-derived accounts walletrpc: add XCreateAccount for wallet-derived accounts Aug 13, 2026
@ellemouton

ellemouton commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed, addressing @ziggie1984's review and the thread about hiding this until there's a proper recovery flow. Fixups autosquashed into their original commits, so the six-commit structure is unchanged.

Experimental, in two ways

The RPC is now XCreateAccount, following the convention already in the tree (XImportMissionControl, XAddLocalChanAliases, XDeleteLocalChanAliases) — the experimental status is visible in the API name itself rather than only in a comment.

On top of that, it requires i_know_what_i_am_doing on release builds, following the AbandonChannel precedent. The two gates say different things and both are worth keeping until recovery handles these accounts:

  • The X prefix marks API instability — it may change or be removed without the usual deprecation period.
  • i_know_what_i_am_doing marks the fund-loss hazard — a seed-only restore does not rediscover funds in an account created this way, and reconstructing one by hand means reproducing its key scope, its account index and the addresses it had issued. That is not about API shape, so an X prefix alone would not carry it.

I first gated purely on build.IsDevBuild(), but that turns out to hide it from the deployments that need it: release images are built with RELEASE_TAGS and carry no dev tag, so a dev-only gate would mean either shipping a custom lnd build or not using the RPC at all. Worth noting the itest image is in the same position — it is built from dev.Dockerfile and build.IsDevBuild() is still false on it, which is how I found this. The acknowledgement keeps the RPC unreachable by accident (nobody gets a custom account by fat-fingering lncli) while leaving it usable by an operator who has read what they are signing up for.

The recovery caveat is stated in the proto, the lncli help and its stderr note after a create, the error string itself, and the release notes. Both gates come off together once recovery finds these accounts; the internal lnwallet.WalletController.CreateAccount keeps its name, since it is not the experimental surface.

Nothing here ships to users regardless until btcwallet's recovery scan covers non-default accounts (RecoveryManager.Resurrect hardcodes DefaultAccountNum, with a standing TODO right there). Happy to open that btcwallet issue alongside the base.Interface one.

Happy to swap the gate for a node-level --experimental flag or a build tag if you'd rather it live there than per-request — just say which shape you prefer.

Review items

  • ImportAccount shares the lock now (renamed accountMtx), covering its check and both branches — the same invariant from the other side.
  • Input validation grouped above the critical section.
  • RPCKeyRing returns a plain ErrRemoteSigningAccountCreation instead of codes.Unimplemented, which would have misled version negotiation.
  • Proto documents that the call is not idempotent and that "already exists" may be a retried-but-successful create.
  • The concurrency test now instruments NextAccount too. Worth calling out: as written it would have passed a lock covering only the lookup, which is precisely the bug it exists to catch. It now fails when the lock is narrowed and passes when it spans check+create — I checked both directions.
  • The itest asserts the custom account's post-spend balance, not just the default account's.
  • lncli rejects np2wkh locally.
  • The typed-error contract change on the wrong-scope path is called out in the commit message.

Verified on the current head: make rpc-check clean, go build ./..., unit tests under both -tags=walletrpc and -tags="walletrpc dev", and both itests (wallet-xcreate_account, wallet-xcreate_account_rejections) pass locally.

@ellemouton
ellemouton force-pushed the agent/walletrpc-create-account branch from 4caca49 to 5d89796 Compare August 13, 2026 23:34
Exercises the property the RPC exists for and that a unit test cannot
reach: an account derived from the wallet's master key is not
watch-only, its funds are reported against it rather than the default
account, and the wallet can sign a spend from it. An imported
account gets as far as funding a PSBT, since that needs only public
data, and fails at finalize; publishing the signed transaction and
asserting it confirms is what separates the two.

Also covers the requests lnd refuses: a duplicate name in any key
scope, the wallet's reserved names, an empty name, and the strict
nested-witness type, which a wallet-derived account cannot honour.
The address type is optional and defaults to taproot, matching the
RPC, because the choice selects the account's key scope and is
permanent for its lifetime.
@ellemouton
ellemouton force-pushed the agent/walletrpc-create-account branch from 5d89796 to 9664abd Compare August 13, 2026 23:48

@ziggie1984 ziggie1984 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@ziggie1984 ziggie1984 added the backport-v0.21.x-branch This label triggers a backport to branch `v0.21.x-branch ` label Aug 14, 2026

@bhandras bhandras left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM 🎉


account := alice.RPC.XCreateAccount(&walletrpc.XCreateAccountRequest{
Name: createAccountName,
AddressType: walletrpc.AddressType_TAPROOT_PUBKEY,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally we could cover different address types too.

be reproduced: accounts are derived from an index that btcwallet assigns
sequentially per key scope, shared with accounts created by ImportAccount.

To keep an account recoverable, record its key scope, the account index

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we make the two address branches explicit in this recovery procedure? Account tracks external_key_count and internal_key_count separately, and NextAddr selects the branch with change (which defaults to false). Replaying a single aggregate count with the default therefore only recreates external addresses and can leave UTXOs on internal/change addresses invisible to the rescan—the likely location of most of the remaining balance after FundPsbt.

I think the actionable procedure should say to preserve both counters over the account lifetime, then call NextAddr(change=false) at least external_key_count times and NextAddr(change=true) at least internal_key_count times before rescanning. The lncli warning should reflect this too: recording only the derivation path at creation is not sufficient because both counters are zero then and grow as the account is used.

maxCreateAccountSpendFee = btcutil.Amount(10_000)
)

// testXCreateAccount asserts the end-to-end behaviour of an account created

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the documented manual reconstruction is currently the only recovery mitigation, could we cover that procedure end to end as well? I am not suggesting that this PR must implement automatic seed-only discovery—the X/acknowledgement gates already make that limitation explicit—but the claim that the funds remain manually recoverable is important enough to verify before users rely on it.

The useful regression test would create a preceding account so the target has a non-trivial index, fund an external address, then spend through FundPsbt so value remains on an internal/change address. Record the target xpub plus both branch counts; restore the same seed into a fresh wallet DB; recreate accounts until the same scope/index is reached and assert the xpub matches; replay NextAddr(change=false) and NextAddr(change=true) using their respective counts; rescan with --reset-wallet-transactions; and finally assert that both balances are found and can be spent. Leaving real value on the internal branch is the part that would catch the recovery issue above.

@ellemouton

Copy link
Copy Markdown
Collaborator Author

@litbot-9000 re review

@ellemouton
ellemouton requested review from litbot-9000 and removed request for litbot-9000 August 14, 2026 20:02
@yyforyongyu
yyforyongyu merged commit b309be5 into lightningnetwork:master Aug 17, 2026
88 of 90 checks passed
@github-actions

Copy link
Copy Markdown

Created backport PR for v0.21.x-branch:

Please cherry-pick the changes locally and resolve any conflicts.

git fetch origin backport-11065-to-v0.21.x-branch
git worktree add --checkout .worktree/backport-11065-to-v0.21.x-branch backport-11065-to-v0.21.x-branch
cd .worktree/backport-11065-to-v0.21.x-branch
git reset --hard HEAD^
git cherry-pick -x 9664abd4f4c95d8e128853e0364e3b4e293fda68
git push --force-with-lease

ziggie1984 added a commit that referenced this pull request Aug 18, 2026
…21.x-branch

[v0.21.x-branch] Backport #11065: walletrpc: add XCreateAccount for wallet-derived accounts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport-v0.21.x-branch This label triggers a backport to branch `v0.21.x-branch ` severity-critical Requires expert review - security/consensus critical

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants